Skip to content

Conversation

@anxi01
Copy link
Member

@anxi01 anxi01 commented Jan 1, 2026

🚀 Why - 해결하려는 문제가 무엇인가요?

  • GA에서 로그인 로그를 확인하기 어려워 어드민에서 일별 로그인 유저 수를 확인하기 위해 작업을 진행했습니다.

✅ What - 무엇이 변경됐나요?

  • 로그인 로그 엔티티 (LogUserLogin) 추가
  • 로그인 성공, 실패 시 로그 쓰기 로직 추가
  • 어드민 사이드바 추가
  • 어드민 사용자 페이지 추가
    • 사용자 상태 컬럼 추가 (활성, 탈퇴)
  • 금일 신규 가입자, 로그인 유저 수 추가

🛠️ How - 어떻게 해결했나요?

  • 로그인 시 로그 저장 과정에서 에러가 발생하더라도 로그인에 영향을 끼치지 않기 위해, 비동기로 처리했습니다.

🖼️ Attachment

스크린샷 2026-01-03 오후 7 57 50

💬 기타 코멘트

Summary by CodeRabbit

  • 새로운 기능

    • 사용자 로그인(성공/실패) 활동을 영구 저장하고 IP, User-Agent 및 실패 사유를 기록
    • 관리자에 일별 사용자 통계(가입·로그인)와 페이지형 사용자 목록 조회 기능 추가
    • 일별 이벤트 통계(이벤트 수·참여자 수) 제공
  • 관리자 UI

    • 새로운 사이드바 레이아웃, 사용자 목록 화면 추가 및 이벤트 목록 레이아웃/스타일 개선
  • 성능

    • 로그인 기록 저장을 비동기 처리하여 인증 흐름 영향 최소화

✏️ Tip: You can customize this high-level summary in your review settings.

@anxi01 anxi01 self-assigned this Jan 1, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Walkthrough

인증 성공/실패 시 요청자 IP(X-Real-IP)와 User‑Agent를 캡처해 비동기적으로 저장하는 로그인 활동 로깅(엔티티·리포지토리·writer)과 핸들러 통합이 추가되었고, 애플리케이션 전역 비동기 실행을 위해 @EnableAsync가 활성화되었습니다. 관리자 영역에 사용자 목록·일별 사용자 통계와 관련 DTO·컨트롤러·템플릿도 추가되었고 서브모듈 커밋 해시가 갱신되었습니다.

Changes

Cohort / File(s) 변경 요약
설정 및 전역 구성
config, src/main/java/com/meetup/server/ServerApplication.java
서브모듈 커밋 해시 업데이트(메타데이터). ServerApplication@EnableAsync 추가로 전역 비동기 지원 활성화.
인증 핸들러
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java, src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java
인증 성공/실패 시 X-Real-IPUser-Agent 캡처 후 LogUserLoginWriter.save(...) 호출로 비동기 로그 기록 추가. 실패 핸들러는 새로운 final 의존성으로 생성자 시그니처 변경(주입 필요). 기존 토큰/리다이렉트 흐름 유지.
도메인 엔티티 및 타입
src/main/java/com/meetup/server/user/domain/LogUserLogin.java, src/main/java/com/meetup/server/user/domain/type/LoginStatus.java
LogUserLogin JPA 엔티티 추가(필드: userId, loginStatus, ipAddress, userAgent, failReason 등). LoginStatus enum 추가(SUCCESS, FAILURE). 긴 문자열은 저장 전 잘라서 처리.
비즈니스/퍼시스턴스 컴포넌트
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java, src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java, src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java
LogUserLoginWriter 컴포넌트 추가(비동기 save 메서드, 예외 로깅). JPA 리포지토리 추가 및 집계 쿼리(countUniqueLoginUsers) 제공. 일별 로그인 집계용 LogUserLoginReader 추가.
관리자 기능(통계/사용자 리스트)
src/main/java/com/meetup/server/admin/application/AdminService.java, src/main/java/com/meetup/server/admin/presentation/AdminController.java, src/main/java/com/meetup/server/admin/dto/response/*
관리자용 DTO 교체/추가(AdminUserResponse, DailyEventStatsResponse, DailyUserStatsResponse). 사용자 페이징 및 일별 사용자 통계 로직 추가. DailyStatsResponse 삭제.
User 리더/리포지토리 확장
src/main/java/com/meetup/server/user/implement/UserReader.java, src/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.java
사용자 전체 페이지 조회(readAll(Pageable)) 및 일별 가입자 카운트 쿼리(countByCreatedAtBetween) 추가.
템플릿/UI 변경
src/main/resources/templates/admin/events.html, src/main/resources/templates/admin/users.html, src/main/resources/templates/admin/fragments/common.html
어드민 레이아웃 변경(사이드바/메인 콘텐츠), 이벤트 통계 변수 변경 및 사용자 목록 템플릿 추가. 사이드바 프래그먼트와 스타일/스크립트 포함.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AuthHandler as OAuth2 Auth Handler
    participant LogWriter as LogUserLoginWriter
    participant Repository as LogUserLoginRepository
    participant Database as DB

    rect rgb(240,248,255)
    note over Client,AuthHandler: 로그인 성공 흐름
    Client->>AuthHandler: 인증 요청
    activate AuthHandler
    AuthHandler->>AuthHandler: IP(`X-Real-IP`) 및 User‑Agent 캡처
    AuthHandler->>LogWriter: save(userId, SUCCESS, ip, ua, null) -- async
    AuthHandler->>Client: 토큰/쿠키 설정 후 리다이렉트
    deactivate AuthHandler
    activate LogWriter
    LogWriter->>Repository: save(LogUserLogin)
    Repository->>Database: INSERT
    deactivate LogWriter
    end

    rect rgb(255,240,240)
    note over Client,AuthHandler: 로그인 실패 흐름
    Client->>AuthHandler: 인증 요청
    activate AuthHandler
    AuthHandler->>AuthHandler: 인증 실패 원인 수집
    AuthHandler->>AuthHandler: IP 및 User‑Agent 캡처
    AuthHandler->>LogWriter: save(null, FAILURE, ip, ua, reason) -- async
    AuthHandler->>Client: 실패 리다이렉트
    deactivate AuthHandler
    activate LogWriter
    LogWriter->>Repository: save(LogUserLogin)
    Repository->>Database: INSERT
    deactivate LogWriter
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

✨ feat, D-0

Suggested reviewers

  • syjdjr

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 로그인 로그 저장 및 일별 로그인 유저 수 확인이라는 주요 변경사항을 명확하게 요약하고 있으며, 실제 변경 내용(LogUserLogin 엔티티 추가, 로그인 성공/실패 로깅, 어드민 대시보드 추가)과 일치합니다.
Description check ✅ Passed PR 설명은 요구된 템플릿 구조를 충분히 따르고 있으며, 문제, 변경사항, 해결 방법, 첨부 자료 및 기타 코멘트 섹션이 모두 작성되어 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
src/main/java/com/meetup/server/ServerApplication.java (1)

7-7: 비동기 실행을 위한 Executor 구성을 권장합니다.

@EnableAsync가 기본 설정으로 활성화되어 있어 SimpleAsyncTaskExecutor를 사용합니다. 이는 작업마다 새 스레드를 생성하므로 높은 부하 상황에서 스레드 고갈이 발생할 수 있습니다.

다음과 같은 커스텀 ThreadPoolTaskExecutor 구성을 권장합니다:

  • 코어 풀 크기, 최대 풀 크기, 큐 용량 설정
  • 스레드 이름 prefix 설정 (모니터링 용이)
  • Rejection policy 구성
🔎 Async Executor 구성 예시

config 패키지에 새로운 설정 클래스 추가:

package com.meetup.server.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-login-log-");
        executor.initialize();
        return executor;
    }
}

Also applies to: 13-13

src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (1)

16-17: 트랜잭션 관리를 고려해주세요.

비동기 메서드에 @Transactional 어노테이션이 없습니다. 로그인 이력 저장은 인증 플로우와 독립적인 트랜잭션으로 처리되는 것이 적절하지만, 명시적으로 트랜잭션 경계를 표현하면 의도가 더 명확해집니다.

별도 트랜잭션으로 처리하려는 의도가 맞다면 현재 구현이 적절합니다. 다만, 실패 시 재시도가 필요하다면 @Transactional(propagation = Propagation.REQUIRES_NEW)를 고려해보세요.

src/main/java/com/meetup/server/user/domain/LogUserLogin.java (1)

33-37: 데이터베이스 스키마와의 일관성을 위해 명시적 길이 제약을 추가하세요.

userAgentfailReason 컬럼에 @Column 애노테이션에서 length를 명시하지 않았지만, 빌더 생성자에서는 255자로 truncate하고 있습니다. 데이터베이스 스키마와 코드의 일관성을 위해 명시적으로 길이 제약을 추가하는 것을 권장합니다.

🔎 제안하는 수정
-    @Column(name = "user_agent")
+    @Column(name = "user_agent", length = 255)
     private String userAgent;
 
-    @Column(name = "fail_reason")
+    @Column(name = "fail_reason", length = 255)
     private String failReason;
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ad07a79 and a614c79.

📒 Files selected for processing (8)
  • config
  • src/main/java/com/meetup/server/ServerApplication.java
  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java
  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
  • src/main/java/com/meetup/server/user/domain/LogUserLogin.java
  • src/main/java/com/meetup/server/user/domain/type/LoginStatus.java
  • src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java
  • src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-12-25T11:50:19.070Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 170
File: src/main/java/com/meetup/server/admin/application/AdminService.java:70-76
Timestamp: 2025-12-25T11:50:19.070Z
Learning: In the MOISAM project, the server and database operate in the Asia/Seoul timezone, and the service is domestic. Therefore, using LocalDate.now() or LocalDateTime.now() without an explicit ZoneId is acceptable for this codebase. Apply this guideline to Java files under src/main/java, but remain vigilant: for time-sensitive data or if future maintenance requires cross-timezone handling, prefer using ZoneId or ZonedDateTime with an explicitZone.

Applied to files:

  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
  • src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java
  • src/main/java/com/meetup/server/user/domain/LogUserLogin.java
  • src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
  • src/main/java/com/meetup/server/ServerApplication.java
  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java
  • src/main/java/com/meetup/server/user/domain/type/LoginStatus.java
📚 Learning: 2025-08-18T05:41:47.443Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 147
File: src/main/java/com/meetup/server/review/infrastructure/jpa/ReviewRepository.java:1-1
Timestamp: 2025-08-18T05:41:47.443Z
Learning: Spring Data JPA with EnableJpaRepositories(basePackages = "com.meetup.server.*.infrastructure") successfully auto-detects custom repository implementations located in infrastructure.querydsl packages, not just infrastructure.jpa packages. The wildcard pattern in basePackages includes subdirectories like querydsl under infrastructure.

Applied to files:

  • src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (10)
config (1)

1-1: 제공되지 않은 구현 파일들의 리뷰가 필요합니다.

현재 제공된 파일은 submodule 메타데이터 변경만 포함하고 있습니다. PR 목표인 "유저 로그인 로그를 DB에 저장하는 기능"에 대한 실제 구현 검토를 위해서는 다음 파일들이 필요합니다:

  • src/main/java/com/meetup/server/user/domain/LogUserLogin.java (새 JPA 엔티티)
  • src/main/java/com/meetup/server/user/domain/type/LoginStatus.java (enum)
  • src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (비동기 저장 로직)
  • src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java (리포지토리)
  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (성공 로그 저장)
  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java (실패 로그 저장)
  • src/main/java/com/meetup/server/ServerApplication.java (@EnableAsync 추가)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (1)

6-7: 잘 구현되었습니다!

Spring의 HttpHeaders 상수를 사용한 리팩토링이 적절합니다. 매직 스트링 대신 표준 상수를 사용하여 코드의 가독성과 유지보수성이 향상되었습니다.

Also applies to: 13-13, 28-28

src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java (1)

1-7: LGTM!

표준 JPA 리포지토리 인터페이스가 올바르게 구현되었습니다. JpaRepository의 기본 CRUD 메서드로 로그인 이력 저장에 충분합니다.

src/main/java/com/meetup/server/user/domain/type/LoginStatus.java (1)

1-6: LGTM!

로그인 상태를 명확하게 표현하는 간단하고 효과적인 enum입니다. SUCCESS와 FAILURE 두 가지 상태로 로그인 이력 추적에 충분합니다.

src/main/java/com/meetup/server/user/domain/LogUserLogin.java (4)

12-16: LGTM!

엔티티 선언과 테이블 매핑이 올바르게 구성되어 있습니다. BaseEntity를 상속하여 생성/수정 시각을 자동으로 관리하고, JPA를 위한 protected 생성자도 적절합니다.


18-24: LGTM!

기본 키 설정이 올바르고, userId를 nullable로 설정한 것은 적절합니다. 인증 실패 시 사용자가 식별되지 않은 경우를 처리할 수 있습니다.


26-31: LGTM!

로그인 상태를 STRING으로 저장하여 가독성을 확보했고, IP 주소 길이를 45로 설정하여 IPv6를 지원합니다.


39-46: LGTM!

빌더 패턴을 사용하여 객체 생성의 유연성을 제공하고, StringUtil.truncate를 통해 긴 문자열을 방어적으로 처리하는 것은 좋은 접근입니다.

src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java (2)

3-4: LGTM!

새로운 의존성과 import가 적절하게 추가되었습니다. HttpHeaders.USER_AGENT 상수를 사용하는 것은 좋은 리팩토링입니다.

Also applies to: 10-10, 23-23


37-37: LogUserLoginWriter.save 메서드가 @Async 애노테이션으로 올바르게 표시되어 있으므로, 로그 저장이 요청 스레드를 차단하지 않습니다. 비동기 지원이 제대로 구현되었습니다.

Comment on lines 35 to 36
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
Copy link

@coderabbitai coderabbitai bot Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for IP extraction patterns and proxy header handling

echo "=== Searching for IP extraction patterns ==="
rg -n -C 2 --type=java 'X-Forwarded-For|X-Real-IP|getRemoteAddr' 

echo ""
echo "=== Checking OAuth2LoginFailureHandler.java ==="
cat -n src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java

Repository: Team-MOISAM/moisam-server

Length of output: 3723


🏁 Script executed:

#!/bin/bash
# Search for IP extraction utility classes and proxy handling

echo "=== Searching for utility classes related to IP extraction ==="
rg -n --type=java 'IpAddress|RemoteAddress|getIp|getRemoteIp' 

echo ""
echo "=== Searching for proxy configuration ==="
rg -n 'proxy|Proxy|PROXY|forwarded|Forwarded' --type=java --type=yml --type=yaml --type=properties

echo ""
echo "=== Checking if there's a configuration for request wrapper or filter ==="
fd -e java | xargs rg -l 'HttpServletRequestWrapper|RequestContextHolder|ServletRequest' | head -10

Repository: Team-MOISAM/moisam-server

Length of output: 1087


🏁 Script executed:

#!/bin/bash
# Search for configuration files and check filters for proxy header handling

echo "=== Looking for configuration files ==="
find . -name "application*" -type f | head -20

echo ""
echo "=== Checking filter implementations for proxy handling ==="
cat -n src/main/java/com/meetup/server/global/filter/CompressFilter.java

echo ""
echo "=== Checking if any filter extends FilterChain or handles RemoteAddr ==="
rg -n --type=java 'extends.*Filter|implements.*Filter' | head -10

echo ""
echo "=== Search for any X-Forwarded or proxy-related config ==="
rg -n 'server\.forward|tomcat\.remoteip|trust.*host' 
</parameter>

Repository: Team-MOISAM/moisam-server

Length of output: 3403


🏁 Script executed:

#!/bin/bash
# Find configuration files in various locations

echo "=== Searching for all configuration files ==="
find . -name "application*" -o -name "*.properties" -o -name "*.yml" | grep -v target | grep -v ".git" | head -20

echo ""
echo "=== Checking src structure ==="
ls -la src/main/resources/ 2>/dev/null || echo "No resources directory found"

echo ""
echo "=== Search for pom.xml to check Spring versions ==="
find . -name "pom.xml" -type f | head -5

echo ""
echo "=== Check if there are any comments about IP or proxy in handlers ==="
rg -n --type=java -B5 -A5 'getRemoteAddr'

Repository: Team-MOISAM/moisam-server

Length of output: 4233


프록시 환경에서의 IP 주소 추출 처리 필요

request.getRemoteAddr()는 프록시나 로드 밸런서 뒤에서 실행될 때 클라이언트의 실제 IP가 아닌 프록시의 IP를 반환합니다. Docker 배포 환경을 고려할 때 X-Forwarded-For 또는 X-Real-IP 헤더를 우선적으로 확인하는 프록시 인식 IP 추출 로직으로 변경하세요. 이 패턴은 OAuth2LoginSuccessHandler에서도 동일하게 나타나므로 두 핸들러 모두 수정이 필요합니다.

🤖 Prompt for AI Agents
In
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java
around lines 35-36, request.getRemoteAddr() is used which returns proxy IPs;
change to proxy-aware extraction by first checking X-Forwarded-For then
X-Real-IP headers (falling back to request.getRemoteAddr()) to obtain the client
IP, and apply the same change to OAuth2LoginSuccessHandler so both handlers use
the same header-priority logic and handle comma-separated X-Forwarded-For values
by taking the first non-empty entry.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#172 (comment)

위 방식대로 처리.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the specific comment from GitHub to understand the context
gh api repos/Team-MOISAM/moisam-server/issues/comments/3703833941 --jq '.body'

Length of output: 648


@anxi01, 이해했습니다! 인프라 레벨에서 externalTrafficPolicy: Local로 설정하여 클라이언트 IP를 보존하는 방식이군요.

이 경우 request.getRemoteAddr()를 사용하는 것이 적절합니다. Traefik에서 소스 IP를 보존하므로, 애플리케이션 레벨에서 별도로 X-Forwarded-For 헤더를 파싱할 필요가 없어 코드가 더 간결해집니다.

현재 단일 노드 k3s 환경에서는 Local 정책을 사용해도 부하 분산에 문제가 없고, 향후 멀티 노드로 확장할 때 다시 고려하신다는 계획도 합리적입니다. 👍

(ㅇㅅㅇ)ノ✨


🧬 Code Graph Analysis Results

filename:src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java

package com.meetup.server.auth.support.handler;

import com.meetup.server.user.domain.type.LoginStatus;
import com.meetup.server.user.implement.LogUserLoginWriter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final LogUserLoginWriter logUserLoginWriter;

    @Value("${app.oauth2.failureRedirectUri}")
    private String redirectUri;

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request, HttpServletResponse response, AuthenticationException exception
    ) throws IOException {

        log.info("OAuth2 login failed: {}", exception.getMessage());

        String ipAddress = request.getHeader("X-Real-IP");
        String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
        logUserLoginWriter.save(null, LoginStatus.FAILURE, ipAddress, userAgent, exception.getMessage());

        String targetUrl = UriComponentsBuilder.fromUriString(redirectUri)
                .queryParam("error", exception.getLocalizedMessage())
                .build().toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

filename:src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider tokenProvider;
    private final CookieUtil cookieUtil;
    private final LogUserLoginWriter logUserLoginWriter;

    @Value("${app.oauth2.successRedirectUri}")
    private String successRedirectUri;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request, HttpServletResponse response, Authentication authentication
    ) throws IOException {

        CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();

        String ipAddress = request.getHeader("X-Real-IP");
        String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
        logUserLoginWriter.save(oAuth2User.getUserId(), LoginStatus.SUCCESS, ipAddress, userAgent, null);

        String accessToken = tokenProvider.createAccessToken(oAuth2User);
        String refreshToken = tokenProvider.createRefreshToken(oAuth2User);

        cookieUtil.setAccessTokenCookie(response, accessToken);
        cookieUtil.setRefreshTokenCookie(response, refreshToken);

        String targetUrl = createRedirectUrlWithTokens(request);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    private String createRedirectUrlWithTokens(HttpServletRequest request) {
        StateParams stateParams = parseState(request.getParameter("state"));

        String redirectUrl;
        if ("visited".equals(stateParams.to) && stateParams.eventId != null && stateParams.placeId != null) {
            redirectUrl = buildVisitedRedirectUrl(stateParams.eventId, stateParams.placeId);
        } else if ("notvisited".equals(stateParams.to) && stateParams.eventId != null && stateParams.placeId != null) {
            redirectUrl = buildNotVisitedRedirectUrl(stateParams.eventId, stateParams.placeId);
        } else {
            redirectUrl = buildDefaultCallbackUrl(stateParams.eventId, stateParams.to);
        }

        log.info("[Redirect URI] - {}", redirectUrl);
        return redirectUrl;
    }

    private StateParams parseState(String state) {
        StateParams params = new StateParams();

        if (state == null || state.isBlank()) return params;

        String decodedState = java.net.URLDecoder.decode(state, java.nio.charset.StandardCharsets.UTF_8);

        for (String param : decodedState.split("&")) {
            String[] keyValue = param.split("=", 2);
            if (keyValue.length == 2) {
                switch (keyValue[0]) {
                    case "to" -> params.to = keyValue[1];
                    case "eventId" -> params.eventId = keyValue[1];
                    case "placeId" -> params.placeId = keyValue[1];
                }
            }
        }
        return params;
    }

    private String buildDefaultCallbackUrl(String eventId, String to) {
        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(successRedirectUri).pathSegment("oauth", "kakao", "callback");
        if (eventId != null && !eventId.isBlank()) {
            uriBuilder.queryParam("eventId", eventId);
        }
        if (to != null && !to.isBlank()) {
            uriBuilder.queryParam("to", to);
        }
        return uriBuilder.build().toUriString();
    }

    private String buildVisitedRedirectUrl(String eventId, String placeId) {
        return UriComponentsBuilder.fromUriString(successRedirectUri)
                .pathSegment("visited", eventId, placeId)
                .build()
                .toUriString();
    }

    private String buildNotVisitedRedirectUrl(String eventId, String placeId) {
        return UriComponentsBuilder.fromUriString(successRedirectUri)
                .pathSegment("notvisited", eventId, placeId)
                .build()
                .toUriString();
    }

    private static class StateParams {
        String to;
        String eventId;
        String placeId;
    }
}

filename:src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java

@Slf4j
@Component
@RequiredArgsConstructor
public class LogUserLoginWriter {

    private final LogUserLoginRepository logUserLoginRepository;

    @Async
    public CompletableFuture<Void> save(Long userId, LoginStatus loginStatus, String ipAddress, String userAgent, String failReason) {
        try {
            logUserLoginRepository.save(
                    LogUserLogin.builder()
                            .userId(userId)
                            .loginStatus(loginStatus)
                            .ipAddress(ipAddress)
                            .userAgent(userAgent)
                            .failReason(failReason)
                            .build()
            );
        } catch (Exception e) {
            log.error("[LogUserLoginWriter]: 유저 로그인 로그 저장에 실패했습니다. userId: {}", userId, e);
        }
        return CompletableFuture.completedFuture(null);
    }
}

filename:src/main/java/com/meetup/server/auth/presentation/filter/JwtAuthenticationFilter.java

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationUtil authenticationUtil;
    private final CookieUtil cookieUtil;
    private final AuthService authService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        String token = cookieUtil.getAccessTokenFromCookie(request);

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            authenticationUtil.setAuthenticationFromRequest(request, token);
        } else {
            reAuthenticateWithRefreshToken(request, response);
        }

        filterChain.doFilter(request, response);
    }

    private void reAuthenticateWithRefreshToken(HttpServletRequest request, HttpServletResponse response) {
        String refreshToken = cookieUtil.getRefreshTokenFromCookie(request);
        if (refreshToken != null) {
            try {
                ReissueTokenResponse reissueTokenResponse = authService.reIssueToken(response, refreshToken);
                cookieUtil.setAccessTokenCookie(response, reissueTokenResponse.accessToken());
                authenticationUtil.setAuthenticationFromRequest(request, reissueTokenResponse.accessToken());
            } catch (UserException e) {
                log.error("Token is invalid, user check failed: {}", e.getMessage());
                cookieUtil.deleteAccessTokenCookie(response);
                cookieUtil.deleteRefreshTokenCookie(response);
            }
        }
    }

}

filename:src/main/java/com/meetup/server/auth/support/AuthenticationUtil.java

@Component
@RequiredArgsConstructor
public class AuthenticationUtil {

    private final JwtTokenProvider jwtTokenProvider;

    public void setAuthenticationFromRequest(HttpServletRequest request, String token) {
        Authentication authentication = makeAuthentication(request, token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private UserAuthentication makeAuthentication(HttpServletRequest request, String token) {
        UserAuthentication authentication = UserAuthentication.from(jwtTokenProvider.getJwtUserDetails(token));
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        return authentication;
    }
}

filename:src/main/java/com/meetup/server/global/presentation/ApiControllerAdvice.java

@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class ApiControllerAdvice{

    private final DiscordAlarmSender discordAlarmSender;

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<?>> handleException(Exception e) {
        log.error("Exception : {}", e.getMessage(), e);
        discordAlarmSender.sendErrorAlert(e);
        return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INTERNAL_ERROR), GlobalErrorType.INTERNAL_ERROR.getStatus());
    }

    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<ApiResponse<?>> handleNoResourceFoundException(NoResourceFoundException e) {
        return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.NOT_FOUND_RESOURCE), GlobalErrorType.NOT_FOUND_RESOURCE.getStatus());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<?>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("MethodArgumentNotValidException : {}", e.getMessage(), e);
        return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.FAILED_REQUEST_VALIDATION), GlobalErrorType.FAILED_REQUEST_VALIDATION.getStatus());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ApiResponse<?>> handleIllegalArgumentException(IllegalArgumentException e) {
        log.error("IllegalArgumentException : {}", e.getMessage(), e);
        return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INVALID_REQUEST_ARGUMENT), GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus());
    }

    @ExceptionHandler(GlobalException.class)
    public ResponseEntity<ApiResponse<?>> handleGlobalException(GlobalException e) {
        log.error("GlobalException : {}", e.getMessage(), e);
        return new ResponseEntity<>(ApiResponse.error(e.getErrorType()), e.getErrorType().getStatus());
    }

}

✏️ Learnings added
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 172
File: src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java:0-0
Timestamp: 2026-01-03T10:47:54.110Z
Learning: Team-MOISAM/moisam-server 프로젝트는 k3s 환경에서 Traefik Service의 externalTrafficPolicy를 Local로 설정하여 클라이언트 IP를 보존한다. 따라서 request.getRemoteAddr()를 사용하여 실제 클라이언트 IP를 얻을 수 있다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 150
File: src/main/java/com/meetup/server/global/email/EmailSender.java:17-26
Timestamp: 2025-08-21T10:38:53.847Z
Learning: Team-MOISAM/moisam-server 프로젝트는 config 디렉토리를 git submodule(https://github.com/Team-MOISAM/config.git)로 사용하여 애플리케이션 설정 파일들을 외부 저장소에서 관리하고 있다. 메일 설정 등의 구성은 이 config submodule의 YAML 파일에 정의되어 있다.

Comment on lines 40 to 42
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
logUserLoginWriter.save(oAuth2User.getUserId(), LoginStatus.SUCCESS, ipAddress, userAgent, null);
Copy link

@coderabbitai coderabbitai bot Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "LogUserLogin*.java" | head -20

Repository: Team-MOISAM/moisam-server

Length of output: 291


🏁 Script executed:

find . -type f -name "*LogUserLoginWriter*.java" | head -20

Repository: Team-MOISAM/moisam-server

Length of output: 141


🏁 Script executed:

rg "class LogUserLogin" --type java -A 5

Repository: Team-MOISAM/moisam-server

Length of output: 1270


🏁 Script executed:

cat src/main/java/com/meetup/server/user/domain/LogUserLogin.java

Repository: Team-MOISAM/moisam-server

Length of output: 1431


🏁 Script executed:

cat src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java

Repository: Team-MOISAM/moisam-server

Length of output: 1057


비동기 메서드의 에러 처리와 null 값 저장 문제를 해결하세요.

검증 결과:

  • ipAddressuserAgent 필드는 DB 컬럼에 nullable=false 제약이 없어 null 값이 저장됩니다
  • LogUserLoginWriter.save()@Async 어노테이션으로 void 반환하므로 저장 실패 시 예외가 묵묵히 무시됩니다
  • @Transactional 어노테이션이 없어 비동기 작업의 트랜잭션 관리가 부재합니다

로그인 감사 기록(audit log)이 손실될 수 있으므로:

  1. ipAddressuserAgent에 대한 null 처리 및 기본값 설정
  2. 비동기 메서드의 예외 처리를 위한 AsyncUncaughtExceptionHandler 설정 또는 방식 변경
  3. 감사 기록 저장 실패에 대한 로깅/모니터링 추가
🤖 Prompt for AI Agents
In
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
around lines 40-42, the code writes ipAddress and userAgent directly and calls
an @Async void save method which can swallow exceptions and lacks transactional
guarantees; fix by ensuring ipAddress and userAgent are null-safe (replace
null/blank with explicit defaults like "UNKNOWN" or "0.0.0.0" and "UNKNOWN-UA"
before calling save), change the async writer API to return a
CompletableFuture/CompletionStage (or Future) so callers can handle
completion/errors, add @Transactional on the writer side (or ensure the async
executor is transaction-aware) and implement error handling either by
configuring an AsyncUncaughtExceptionHandler or by handling exceptions on the
CompletableFuture (log and increment audit-failure metric), and add explicit
logging/monitoring when save fails so audit-record loss is visible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ipAddress, userAgent가 혹 빈값으로 들어올 수 있어 우선 nullable하게 처리.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anxi01, nullable 처리로 진행하시는 것 이해했습니다.

X-Real-IP 헤더나 User-Agent가 누락될 수 있는 엣지 케이스를 고려하신 실용적인 결정입니다. 다만 몇 가지 추가로 고려해보시면 좋을 점들이 있습니다:

  1. Null 값 가시성: ipAddress나 userAgent가 null로 저장될 때를 대비해 로깅을 추가하면 프록시 설정 문제나 이상 트래픽을 감지하는 데 도움이 됩니다.

    String ipAddress = request.getHeader("X-Real-IP");
    String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
    
    if (ipAddress == null || ipAddress.isBlank()) {
        log.warn("Login log: X-Real-IP header is missing for user {}", oAuth2User.getUserId());
    }
  2. 비동기 에러 핸들링: LogUserLoginWriter.save()@Async로 동작하므로 저장 실패 시 예외가 조용히 무시될 수 있습니다. 로그인 감사 기록은 중요하므로, 필요시 CompletableFuture 반환값을 활용한 에러 핸들링이나 AsyncUncaughtExceptionHandler 설정을 검토해보시는 것을 권장드립니다.

현재 구현으로 진행하시되, 향후 로그인 이력 데이터를 분석하실 때 null 값 비율을 모니터링하시면 프록시 설정이 올바른지 확인하는 데 도움이 될 것 같습니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e957eaa and 5fd6a37.

📒 Files selected for processing (1)
  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-25T11:50:19.070Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 170
File: src/main/java/com/meetup/server/admin/application/AdminService.java:70-76
Timestamp: 2025-12-25T11:50:19.070Z
Learning: In the MOISAM project, the server and database operate in the Asia/Seoul timezone, and the service is domestic. Therefore, using LocalDate.now() or LocalDateTime.now() without an explicit ZoneId is acceptable for this codebase. Apply this guideline to Java files under src/main/java, but remain vigilant: for time-sensitive data or if future maintenance requires cross-timezone handling, prefer using ZoneId or ZonedDateTime with an explicitZone.

Applied to files:

  • src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
🧬 Code graph analysis (1)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (1)
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (1)
  • Component (10-28)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (2)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (2)

6-7: LGTM!

새로운 로그인 로깅 기능에 필요한 import문들이 적절하게 추가되었습니다.

Also applies to: 13-13, 20-20


29-29: LGTM!

의존성 주입 필드가 적절하게 선언되었습니다.

@anxi01
Copy link
Member Author

anxi01 commented Jan 1, 2026

[Issue]

로그인 로그에 실제 유저 IP 대신 k3s 내부 사설 IP(10.x.x.x)가 기록됨.

[Cause]

기본 네트워크 정책(externalTrafficPolicy: Cluster)에 의한 SNAT(Source Network Address Translation) 발생.

externalTrafficPolicy 설정은 Cluster(default)와 Local, 2가지 옵션을 가질 수 있으며 Cluster의 경우 Source IP가 변경되고 Second hop을 야기할 수 있으나 고른 부하 분산을 할 수 있고, Local의 경우 Source IP를 보존할 수 있으나 불균형한 부하 분산을 한다고 되어 있습니다.
참고: https://nangman14.tistory.com/72

[Solution]

Traefik 서비스 설정을 externalTrafficPolicy: Local로 변경 -> 클라이언트 IP 확보

LB에서 Spring Pod으로 hop을 진행하며 분산해야 하지만, 현재 단일 노드이기 때문에 Local로 해도 문제 없을 것으로 판단했습니다.
추후 k3s에서 멀티 노드를 사용한다면, 그때 Cluster로 변경 및 다른 방안을 고려해봐야 할 것 같습니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (7)
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (1)

20-36: CompletableFuture 에러 처리 방식 개선을 고려해주세요.

이전 리뷰 코멘트를 반영하여 try-catch와 에러 로깅을 추가해주신 점은 좋습니다. 다만 현재 구현은 예외 발생 시에도 CompletableFuture.completedFuture(null)을 반환하여 항상 성공으로 완료됩니다.

현재 호출부(OAuth2LoginSuccessHandler, OAuth2LoginFailureHandler)에서 반환값을 대기하지 않는 fire-and-forget 방식이므로 실질적인 문제는 없지만, CompletableFuture의 의미론과 일관성을 위해 다음 중 하나를 고려해보세요:

  1. 예외 발생 시 CompletableFuture.failedFuture(e) 반환 (호출부에서 에러 관찰 가능)
  2. 완전한 fire-and-forget이라면 void 반환으로 단순화
🔎 제안: 예외 발생 시 실패 상태로 완료
 @Async
 public CompletableFuture<Void> save(Long userId, LoginStatus loginStatus, String ipAddress, String userAgent, String failReason) {
     try {
         logUserLoginRepository.save(
                 LogUserLogin.builder()
                         .userId(userId)
                         .loginStatus(loginStatus)
                         .ipAddress(ipAddress)
                         .userAgent(userAgent)
                         .failReason(failReason)
                         .build()
         );
+        return CompletableFuture.completedFuture(null);
     } catch (Exception e) {
         log.error("[LogUserLoginWriter]: 유저 로그인 로그 저장에 실패했습니다. userId: {}", userId, e);
+        return CompletableFuture.failedFuture(e);
     }
-    return CompletableFuture.completedFuture(null);
 }
src/main/resources/templates/admin/users.html (2)

6-125: 스타일을 외부 CSS 파일로 분리하는 것을 고려하세요.

인라인 스타일이 125줄이나 되어 파일 크기가 크고 재사용성이 떨어집니다. 관리자 페이지의 공통 스타일을 외부 CSS 파일로 분리하면 유지보수성과 일관성을 개선할 수 있습니다.


152-173: 사용자 목록이 비어있을 때의 처리를 추가하는 것을 권장합니다.

현재 userPages.content가 비어있을 경우 빈 테이블만 표시됩니다. 사용자 경험 개선을 위해 "등록된 사용자가 없습니다"와 같은 메시지를 표시하는 것이 좋습니다.

🔎 제안하는 개선
         </thead>
         <tbody>
+        <tr th:if="${userPages.content.isEmpty()}">
+            <td colspan="6" style="text-align: center; padding: 40px;">등록된 사용자가 없습니다.</td>
+        </tr>
         <tr th:each="user : ${userPages.content}">
             <td th:text="${user.userId}">ID</td>
src/main/resources/templates/admin/fragments/common.html (2)

113-117: .main-content 스타일이 events.html에서 중복 정의됨.

이 스타일 블록이 events.html의 13-17번 라인에도 정의되어 있습니다. 프래그먼트가 포함될 때 스타일 충돌이나 유지보수 문제가 발생할 수 있습니다.

스타일을 한 곳에서만 정의하거나, 공통 CSS 파일로 분리하는 것을 권장합니다.


120-139: 접근성 개선을 위한 ARIA 속성 추가 고려.

스크린 리더 사용자를 위해 네비게이션에 ARIA 속성을 추가하면 좋습니다.

🔎 접근성 개선 제안
-    <div class="sidebar" id="sidebar">
+    <div class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation">
         <div class="sidebar-header">
             <h3>MOISAM Admin</h3>
-            <button id="toggle-btn" type="button">☰</button>
+            <button id="toggle-btn" type="button" aria-label="Toggle sidebar" aria-expanded="true">☰</button>
         </div>
-        <ul>
+        <ul role="menubar">
src/main/resources/templates/admin/events.html (2)

13-17: .main-content 스타일 중복.

이 스타일이 common.html 프래그먼트(113-117번 라인)에도 정의되어 있습니다. 한 곳에서만 정의하여 유지보수성을 높이세요.


187-201: 이벤트가 없을 때 빈 상태(empty state) 처리 고려.

eventPages.content가 비어있을 경우 테이블 헤더만 표시됩니다. 사용자 경험을 위해 빈 상태 메시지를 추가하면 좋습니다.

🔎 빈 상태 처리 제안
             <tbody>
             <tr th:each="event : ${eventPages.content}">
                 <!-- ... existing code ... -->
             </tr>
+            <tr th:if="${#lists.isEmpty(eventPages.content)}">
+                <td colspan="7" style="text-align: center; padding: 40px; color: #6c757d;">
+                    등록된 이벤트가 없습니다.
+                </td>
+            </tr>
             </tbody>
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 39ec7d2 and 576bb21.

📒 Files selected for processing (14)
  • src/main/java/com/meetup/server/admin/application/AdminService.java
  • src/main/java/com/meetup/server/admin/dto/response/AdminUserResponse.java
  • src/main/java/com/meetup/server/admin/dto/response/DailyEventStatsResponse.java
  • src/main/java/com/meetup/server/admin/dto/response/DailyStatsResponse.java
  • src/main/java/com/meetup/server/admin/dto/response/DailyUserStatsResponse.java
  • src/main/java/com/meetup/server/admin/presentation/AdminController.java
  • src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java
  • src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java
  • src/main/java/com/meetup/server/user/implement/UserReader.java
  • src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
  • src/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.java
  • src/main/resources/templates/admin/events.html
  • src/main/resources/templates/admin/fragments/common.html
  • src/main/resources/templates/admin/users.html
💤 Files with no reviewable changes (1)
  • src/main/java/com/meetup/server/admin/dto/response/DailyStatsResponse.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-25T11:50:19.070Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 170
File: src/main/java/com/meetup/server/admin/application/AdminService.java:70-76
Timestamp: 2025-12-25T11:50:19.070Z
Learning: In the MOISAM project, the server and database operate in the Asia/Seoul timezone, and the service is domestic. Therefore, using LocalDate.now() or LocalDateTime.now() without an explicit ZoneId is acceptable for this codebase. Apply this guideline to Java files under src/main/java, but remain vigilant: for time-sensitive data or if future maintenance requires cross-timezone handling, prefer using ZoneId or ZonedDateTime with an explicitZone.

Applied to files:

  • src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java
  • src/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.java
  • src/main/java/com/meetup/server/user/implement/UserReader.java
  • src/main/java/com/meetup/server/admin/presentation/AdminController.java
  • src/main/java/com/meetup/server/admin/application/AdminService.java
  • src/main/java/com/meetup/server/admin/dto/response/DailyEventStatsResponse.java
  • src/main/java/com/meetup/server/admin/dto/response/AdminUserResponse.java
  • src/main/java/com/meetup/server/admin/dto/response/DailyUserStatsResponse.java
  • src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java
🧬 Code graph analysis (3)
src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java (1)
src/main/java/com/meetup/server/user/implement/UserReader.java (1)
  • Component (17-42)
src/main/java/com/meetup/server/user/implement/UserReader.java (2)
src/main/java/com/meetup/server/event/implement/EventReader.java (1)
  • Component (18-46)
src/main/java/com/meetup/server/startpoint/implement/StartPointReader.java (1)
  • Component (20-56)
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (2)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java (1)
  • Slf4j (18-45)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (1)
  • Slf4j (21-120)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (17)
src/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.java (1)

17-17: LGTM!

Spring Data JPA의 메서드 네이밍 컨벤션을 따르는 표준적인 쿼리 메서드입니다.

src/main/java/com/meetup/server/admin/application/AdminService.java (2)

77-89: LGTM!

기존 getAllEvents 메서드와 동일한 패턴을 따르는 일관된 구현입니다.


99-105: LGTM!

일일 사용자 통계를 조회하는 로직이 명확하고 간결합니다. LocalDate.now() 사용은 Asia/Seoul 타임존에서 운영되는 프로젝트 특성상 적절합니다.

src/main/resources/templates/admin/users.html (3)

135-150: 통계 카드 구현이 잘 되어 있습니다.

null 체크와 숫자 포맷팅이 적절하게 구현되어 있습니다.


175-191: 페이지네이션 로직이 올바르게 구현되어 있습니다.

블록 기반 페이지네이션 계산이 정확하고, Spring Data의 0-based 인덱싱을 올바르게 처리하고 있습니다.


129-193: admin-wrapper div의 닫는 태그가 누락되었습니다.

129번 라인에서 <div class="admin-wrapper">를 열었지만 닫는 </div> 태그가 없습니다. 193번 라인 이전에 </div>를 추가해야 합니다.

🔎 제안하는 수정
         </div>
     </div>
+</div>
 
 </body>
 </html>

Likely an incorrect or invalid review comment.

src/main/java/com/meetup/server/admin/presentation/AdminController.java (3)

7-9: 새로운 DTO 임포트가 적절합니다.

관리자 응답을 위한 DTO들이 올바르게 임포트되었습니다.


77-81: 일일 통계 추가가 적절하게 구현되었습니다.

이벤트 페이지에 일일 통계를 추가하는 로직이 올바르게 구현되었으며, 새로 추가된 getAllUsers 메서드와 일관된 패턴을 따릅니다.


85-96: 사용자 목록 엔드포인트가 잘 구현되었습니다.

getAllEvents 메서드와 일관된 패턴을 따르고 있으며, 페이지네이션과 통계 조회 로직이 적절합니다. SecurityConfig에서 /admins/** 경로에 대해 ADMIN 권한을 요구하는 보안 설정이 적용되어 있으므로 인증/권한 검증이 제대로 처리되고 있습니다.

src/main/java/com/meetup/server/user/implement/UserReader.java (2)

8-14: 필요한 임포트가 적절하게 추가되었습니다.

페이지네이션과 날짜 처리를 위한 표준 라이브러리가 올바르게 임포트되었습니다.


37-41: 일일 가입자 수 조회 로직이 올바르게 구현되었습니다.

날짜 범위 계산이 정확하며, 코드베이스의 다른 Reader들(EventReader.readDailyEventCount, StartPointReader.readDailyParticipantCount)과 일관된 패턴을 따릅니다.

Based on learnings, 프로젝트의 서버와 데이터베이스가 Asia/Seoul 타임존에서 운영되므로 명시적인 ZoneId 없이 LocalDate.now()를 사용하는 것이 적절합니다.

src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java (1)

1-22: 로그인 로그 Reader가 깔끔하게 구현되었습니다.

구현이 간결하고 명확하며, UserReaderreadDailyRegisterUserCount 메서드와 일관된 패턴을 따릅니다. 날짜 범위 계산 로직도 정확합니다.

Based on learnings, Asia/Seoul 타임존에서 운영되므로 LocalDateLocalDateTime 사용이 적절합니다.

src/main/java/com/meetup/server/admin/dto/response/AdminUserResponse.java (2)

7-14: DTO record 정의가 적절합니다.

불변 record를 사용하여 DTO를 정의한 것이 좋으며, 필드 타입과 네이밍이 명확합니다.


15-24: 팩토리 메서드가 올바르게 구현되었습니다.

User 도메인 객체에서 AdminUserResponse로의 매핑이 정확하며, static 팩토리 메서드 패턴을 적절하게 사용했습니다.

src/main/resources/templates/admin/fragments/common.html (1)

141-146: 토글 기능 구현 적절함.

사이드바 접기/펼치기 로직이 간결하게 구현되었습니다. 위의 접근성 제안을 적용할 경우, aria-expanded 속성도 함께 토글하면 좋습니다.

src/main/resources/templates/admin/events.html (2)

203-227: 페이지네이션 구현 적절함.

블록 기반 페이지네이션 로직이 올바르게 구현되었습니다. 첫 페이지/마지막 페이지 네비게이션과 조건부 렌더링이 잘 처리되어 있습니다.


152-172: 사이드바 통합 및 통계 표시 구현 적절함.

프래그먼트 포함 구문이 올바르고, dailyEventStats에 대한 null 체크와 숫자 포맷팅이 잘 적용되었습니다.

@anxi01 anxi01 changed the title [MOISAM-246] 유저 로그인 로그를 DB에 저장한다 [MOISAM-246] 유저의 로그인 로그를 저장하여 일별 로그인 유저 수를 어드민에서 확인한다 Jan 3, 2026
@anxi01 anxi01 requested a review from syjdjr January 3, 2026 11:04
@anxi01 anxi01 added ♻️ refactor 코드 리팩토링 ✨ feat 새로운 기능 추가 D-2 🔥 remove 파일을 삭제만 한 경우 🚚 rename 파일 혹은 폴더명을 수정만 한 경우 D-1 and removed D-2 labels Jan 3, 2026
Copy link
Contributor

@syjdjr syjdjr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!

@anxi01 anxi01 merged commit 54f1200 into develop Jan 4, 2026
4 checks passed
@anxi01 anxi01 deleted the feat/MOISAM-246 branch January 4, 2026 07:45
anxi01 added a commit that referenced this pull request Jan 4, 2026
* FEAT: (MOISAM-246) 유저 로그인 로그 엔티티, 리포지토리를 설계한다

* FEAT: (MOISAM-246) 로그인 성공/실패 log를 저장한다

* REFACTOR: (MOISAM-246) UserAgent를 Spring Http 상수로 변경한다

* REFACTOR: (MOISAM-246) X-Real-IP에서 IP주소를 받아오도록 변경한다

* FEAT: (MOISAM-246) 로그인 이력 저장 실패 시 로깅을 수행한다

* FEAT: (MOISAM-246) 사이드바 프래그먼트를 추가한다

* FEAT: (MOISAM-246) 어드민에서 금일 가입자 수, 로그인 유저 수 및 가입 유저 정보를 조회한다

* FEAT: (MOISAM-246) 어드민 User View를 추가한다

* FEAT: (MOISAM-246) 유저 탈퇴 여부 및 탈퇴 일시를 추가한다
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

D-1 ✨ feat 새로운 기능 추가 ♻️ refactor 코드 리팩토링 🔥 remove 파일을 삭제만 한 경우 🚚 rename 파일 혹은 폴더명을 수정만 한 경우

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants